前端 undo & redo 功能是非常常见的,通常会使用命令模式来实现。
下面以一个低代码编辑器的例子,来介绍 JavaScript 是如何使用命令模式来实现 undo & redo 功能的。
首先,我们来看一下命令模式的结构示意图。
在命令模式中,关键是定义了一个 Command 接口,它有 execute 和 undo 两个方法,具体的命令类都需要实现这两个方法。调用者(Invoker)在调用命令的时候,只需要执行命令对象的 execute 和 undo 方法即可,而不用关心这两个方法具体做了什么。实际上这两方法的具体实现,通常都是在接收者(Receiver)中,命令类中通常有一个接收者实例,命令类只需要调用接收者实例方法即可。
OK,我们来看一下,我们的低代码编辑器的状态库(简化版的)。它是使用 zustand 定义的,它有一个组件列表 componentList,以及相关的3个方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| import { createStore } from "zustand/vanilla";
const store = createStore((set) => ({ componentList: [], addComponent: (comp) => set((state) => ({ componentList: [...state.componentList, comp] })), removeComponent: (comp) => set((state) => ({ componentList: state.componentList.filter((v) => v.id !== comp.id), })), updateComponentProps: (comp, newProps) => set((state) => { const index = state.componentList.findIndex((v) => v.id === comp.id); if (index > -1) { const list = [...state.componentList]; return { componentList: [ ...list.slice(0, index), { ...comp, props: newProps }, ...list.slice(index + 1), ], }; } }), }));
export default store;
|
接下来,我们看一下相关命令类的实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75
| class Command { constructor() {}
execute() { throw new Error("未重写 execute 方法!"); }
undo() { throw new Error("未重写 undo 方法!"); } }
export class AddComponentCommand extends Command { editorStore; comp;
constructor(editorStore, comp) { super(); this.editorStore = editorStore; this.comp = comp; }
execute(comp) { this.editorStore.getState().addComponent(this.comp); }
undo() { this.editorStore.getState().removeComponent(this.comp); } }
export class RemoveComponentCommand extends Command { editorStore; comp;
constructor(editorStore, comp) { super(); this.editorStore = editorStore; this.comp = comp; }
execute() { this.editorStore.getState().removeComponent(this.comp); }
undo() { this.editorStore.getState().addComponent(this.comp); } }
export class UpdateComponentPropsCommand extends Command { editorStore; comp; newProps; prevProps;
constructor(editorStore, comp, newProps) { super(); this.editorStore = editorStore; this.comp = comp; this.newProps = newProps; }
execute() { const { updateComponentProps, componentList } = this.editorStore.getState(); this.prevProps = componentList.find((v) => v.id === this.comp.id)?.props; updateComponentProps(this.comp, this.newProps); }
undo() { const { updateComponentProps } = this.editorStore.getState(); updateComponentProps(this.comp, this.prevProps); } }
|
我们实现了 AddComponentCommand、RemoveComponentCommand 和 UpdateComponentPropsCommand 3个命令类,在我们的命令类中都有一个 editorStore 属性,它在这里充当了 Receiver 接收者,因为编辑器相关操作我们都定义在状态库中。
其中 AddComponentCommand 和 RemoveComponentCommand 相对比较简单,有直接的操作可以实现撤销。UpdateComponentPropsCommand 就稍微复杂一点,我们更新了属性之后,没有一个直接的操作可以撤销修改,这种情况我们通常需要增加一个属性,记录修改之前的状态,用于实现撤销功能,在 UpdateComponentPropsCommand 中就是 prevProps。
到这里,我们的命令类都已经实现了,要实现 undo 和 redo 功能,通常我们还需要实现一个命令管理类,它需要实现 execute、undo 和 redo 三个方法。它的具体实现多种方法,我们这里使用两个栈(Stack)来实现,具体代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| class CommandManager { undoStack = []; redoStack = [];
execute(command) { command.execute(); this.undoStack.push(command); this.redoStack = []; }
undo() { const command = this.undoStack.pop(); if (command) { command.undo(); this.redoStack.push(command); } }
redo() { const command = this.redoStack.pop(); if (command) { command.execute(); this.undoStack.push(command); } } }
export default new CommandManager();
|
有了这些,接下来我们可以进入测试环节了,下面是我们的测试代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| import store from "./store/editorStore"; import cmdManager from "./commands/cmdManager";
store.subscribe((state) => console.log(JSON.stringify(state.componentList)) );
const comp1 = { id: 101, componentName: "Comp1", props: {}, children: null, }; const comp2 = { id: 102, componentName: "Comp2", props: {}, children: null, };
cmdManager.execute(new AddComponentCommand(store, comp1)); cmdManager.execute(new AddComponentCommand(store, comp2)); cmdManager.undo(); cmdManager.redo();
cmdManager.execute(new RemoveComponentCommand(store, comp1)); cmdManager.undo();
cmdManager.execute( new UpdateComponentPropsCommand(store, comp1, { visible: true }) ); cmdManager.undo();
|
测试结果如下,说明我们的代码正常工作了。
至此,我们已经完成了完整的第一个版本了。但是代码还有优化的空间,我们继续改进一下。
第一点,执行命令的地方,要手动 new 命令类,传入 store 状态库,有较多的模板代码。
1 2 3 4
| cmdManager.execute(new AddComponentCommand(store, comp1)); cmdManager.execute(new AddComponentCommand(store, comp2)); cmdManager.undo(); cmdManager.redo();
|
我们可以参考 js 原生方法 document.execCommand 实现一个 executeCommand () 方法,这样执行命令就变成了 executeCommand(commandName, …args) 这样,更为方便。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38
| import cmdManager from "./cmdManager"; import { AddComponentCommand, RemoveComponentCommand, UpdateComponentPropsCommand, } from "./index"; import store from "../store/editorStore";
const commondActions = { addComponent(...args) { const cmd = new AddComponentCommand(store, ...args); cmdManager.execute(cmd); },
removeComponent(...args) { const cmd = new RemoveComponentCommand(store, ...args); cmdManager.execute(cmd); },
updateComponentProps(...args) { const cmd = new UpdateComponentPropsCommand(store, ...args); cmdManager.execute(cmd); },
undo() { cmdManager.undo(); },
redo() { cmdManager.redo(); }, };
const executeCommand = (cmdName, ...args) => { commondActions[cmdName](...args); };
export default executeCommand;
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| store.subscribe((state) => console.log(JSON.stringify(state.componentList)) );
const comp1 = { id: 101, componentName: "Comp1", props: {}, children: null, };
const comp2 = { id: 102, componentName: "Comp2", props: {}, children: null, };
executeCommand("addComponent", comp1); executeCommand("addComponent", comp2); executeCommand("undo"); executeCommand("redo");
executeCommand("removeComponent", comp1); executeCommand("undo");
executeCommand("updateComponentProps", comp1, { visible: true }); executeCommand("undo");
|
第二点,CommandManager 其实使用一个栈(Stack)加上指针也可以实现,我们参考了网上的代码(JavaScript command pattern for undo and redo),优化之后代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| class CommandManager { _commandsList = []; _currentCommand = -1;
execute(command) { command.execute(); this._currentCommand++; this._commandsList[this._currentCommand] = command; if (this._commandsList[this._currentCommand + 1]) { this._commandsList.splice(this._currentCommand + 1); } }
undo() { const command = this._commandsList[this._currentCommand]; if (command) { command.undo(); this._currentCommand--; } }
redo() { const command = this._commandsList[this._currentCommand + 1]; if (command) { command.execute(); this._currentCommand++; } } }
export default new CommandManager();
|
OK,这就是我们的第二个版本了。
参考资料:
《Head First 设计模式 - 命令模式》
javascript - 基于Web的svg编辑器(1)——撤销重做功能 - 个人文章 - SegmentFault 思否
JavaScript command pattern for undo and redo (s24.com)